本篇介紹 ES2018 (ES9) 提供的 RegExp Named Capture Groups。
numbered capture groups 可讓你引用 RegExp match 的字串的某個部份
每個 capture group 都分配一個唯一的編號,並可使用該編號來引用,但缺點是會讓 RegExp 更不易讀,且不易重構。
例如:從 /(\d{4})-(\d{2})-(\d{2})/
你的出來是 match 哪種資料的 RegExp pattern?無法直接看出來,因為你知道格式必須是數字,並且有些數字之間要用 -
分隔。
其實剛剛的 pattern 是拿來 match YYYY-MM-DD
的日期格式,例如:2020-09-25
。
let dateFormat = /(\d{4})-(\d{2})-(\d{2})/;
let result = dateFormat.exec('2020-09-25');
console.log(result);
// ["2020-09-25", "2020", "09", "25", index: 0, input: "2020-09-25", groups: undefined]
console.log(result[1]);
// "2020"
console.log(result[2]);
// "09"
console.log(result[3]);
// "25"
但還有另一個問題,後面的兩個 capture group 都是連續接著兩個數字,你要怎麼確定哪一個是月份,哪一個是日期?
let dateFormat = /(\d{4})-(\d{2})-(\d{2})/;
let result = dateFormat.exec('2020-25-09');
console.log(result);
// ["2020-25-09", "2020", "25", "09", index: 0, input: "2020-25-09", groups: undefined]
console.log(result[1]);
// "2020"
console.log(result[2]);
// "25"
console.log(result[3]);
// "09"
而本篇介紹的 named capture groups 提案就是一個很好的解決方案。
在 ECMAScript 的 capture group 可用 {?<name>...}
語法為 capture group 命名,該名稱在 spec 內稱為 RegExpIdentifierName,每個名稱都是唯一的。
以剛剛的日期格式為例,可以改為這樣:
let dateFormat = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let result = dateFormat.exec('2020-09-25');
console.log(result);
// ["2020-09-25", "2020", "09", "25", index: 0, input: "2020-09-25", groups: {…}]
原本是透過 non-named capture group 取得每個 group:
console.log(result[1]);
// "2020"
console.log(result[2]);
// "09"
console.log(result[3]);
// "25"
而 named capture group 可以從 RegExp 的結果中的 groups
property 取得 named group:
console.log(result.groups);
// {year: "2020", month: "09", day: "25"}
也可搭配解構來使用:
let dateFormat = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let { groups: {year, month, day} } = dateFormat.exec('2020-09-25');
console.log(year);
// "2020"
console.log(month);
// "09"
console.log(day);
// "25"
例如:此 pattern 只能 match 第一個字元和最後一個字元是 '
(單引號) 或 "
(雙引號),而引號的中間可以是任何字元。但再加上一個規則,只要一邊是單引號,另一邊就一定是單引號,雙引號也是,必須成雙成對。
也許你會這樣寫:
/^(["'])(.*)(["'])$/.exec(`'Titan'`);
// ["'Titan'", "'", "Titan", "'", index: 0, input: "'Titan'", groups: undefined]
/^(["'])(.*)(["'])$/.exec(`"Titan"`);
// [""Titan"", """, "Titan", """, index: 0, input: ""Titan"", groups: undefined]
但有一個問題,第一個和第三個 capture group 只要是 '
(單引號) 或 "
(雙引號) 都會 match,這樣就會發生一邊是單引號,一邊是雙引號:
/^(["'])(.*)(["'])$/.exec(`'Titan"`);
// ["'Titan"", "'", "Titan", """, index: 0, input: "'Titan"", groups: undefined]
/^(["'])(.*)(["'])$/.exec(`"Titan'`);
// [""Titan'", """, "Titan", "'", index: 0, input: ""Titan'", groups: undefined]
此時就能用 backreference 來解決,先來看程式碼:
/^(["'])(.*)\1$/.exec(`"Titan"`);
// [""Titan"", """, "Titan", index: 0, input: ""Titan"", groups: undefined]
/^(["'])(.*)\1$/.exec(`'Titan'`);
// ["'Titan'", "'", "Titan", index: 0, input: "'Titan'", groups: undefined]
\1
(語法為 \n
,n
代表第幾個 numbered capture group) 是 backreference (也可稱為 numbered reference),代表第一個 numbered capture group 所 match 的結果,所以當第一個字元是單引號時,最後一個字元就必須是單引號,不能一邊是單引號,一般是雙引號。
所以這樣就能解決兩邊一定要相同引號的問題,否則就不會 match:
/^(["'])(.*)\1$/.exec(`'Titan"`);
// null
/^(["'])(.*)\1$/.exec(`"Titan'`);
// null
但 \n
這種 backreference 的缺點是不易讀,若你的 RegExp 比較複雜,同時用了不同編號的 numbered reference 就會開始混亂了!
所以本篇介紹的 named capture group 就能派上用場了!
\k<name>
這種語法 (也可稱為 named reference) 就是為了解決這個問題!它代表與該 named capture group name
match 的結果:
let stringFormat = /^(?<quote>["'])(?<string>.*)\k<quote>$/u;
let result = stringFormat.exec(`'Titan'`);
console.log(result);
// ["'Titan'", "'", "Titan", index: 0, input: "'Titan'", groups: {…}]
console.log(result.groups);
// {quote: "'", string: "Titan"}
當然 numbered reference 和 named reference 是可以混用的 (雖然我不太建議這樣)。用另一個範例為例:
let someNumberFormat = /^(?<part>\d)-\k<part>-\1$/u;
let result1 = someNumberFormat.exec('0-0-0');
console.log(result1);
// ["0-0-0", "0", index: 0, input: "0-0-0", groups: {…}]
console.log(result1.groups);
// {part: "0"}
let result2 = someNumberFormat.exec('0-0-1');
console.log(result1);
// null
named capture group 也可在 String.prototype.replace()
作為取代值使用,使用 $name
語法就能存取 named capture group。例如:
let dateFormat = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let result = '2020-09-25'.replace(dateFormat, '$<day>/$<month>/$<year>');
console.log(result);
// 25/09/2020
不過要請注意,傳給 String.prototype.replace()
的值是 ordinary string literal (普通的字串字面值),而不是 template literal (模板字面值,即 ${name}
),不要搞混了。因為語法 $<name>
會解析出其中的 name
,而不是作為變數。
提供一個好記的方法,只要跟 named capture group 相關的都會有 <name>
這樣的與法,而此提案就是為了能跟 template literal 做區別才這樣設計的。
若 String.prototype.replace()
的第二個 argument 是 callback 函數,可透過名為 groups
的新參數來存取 named capture group。
callback 完整的參數如下:
String.prototype.replace(
regexp,
function (matched, capture1, ..., captureN, position, string, groups) {
// ...
}
);
例如:
let dateFormat = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let result = '2020-09-25'.replace(dateFormat, (...args) => {
let {day, month, year} = args[args.length - 1];
return `${day}/${month}/${year}`;
});
console.log(result);
// 25/09/2020
在 tc39/proposal-regexp-named-groups 這份提案中有一些討論蠻有趣的:
為何會在 RegExp 結果物件上多加一個 groups
property?
length
、index
和 input
property 重複時,那就慘了!groups
property 內,且值為一個物件:
RegExp.prototype.exec()
的 RegExp 結果物件上多加一個 groups
property,也不會造成任何 Web 相容性的問題只有使用 named capture group 才會在 RegExp 結果物件中建立 groups
property,否則為 undefined
。
groups
property 內不包含 numbered group property,只包含 named group。